#!/usr/bin/env python
#
# Copyright 2013 Greg Neagle
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


import glob
import sys
import os
import optparse
import plistlib
import pprint
import subprocess
#import time

from xml.parsers.expat import ExpatError

from autopkglib import BUNDLE_ID
from autopkglib import get_pref, get_all_prefs
from autopkglib import AutoPackagerError, AutoPackager

_report_plist = False

def log(msg, error=False):
    '''Message logger, prints to stdout/stderr and suppress stdout
    when --report-plist is used.'''
    if error:
        print >> sys.stderr, msg
    elif not _report_plist and not error:
        print >> sys.stdout, msg

def log_err(msg):
    '''Message logger for errors.'''
    log(msg, error=True)

def recipe_has_step_processor(recipe, processor):
    '''Does the recipe object contain at least one step with the
    named Processor?'''
    if "Process" in recipe:
        processors = [step.get("Processor") for step in recipe["Process"]]
        if processor in processors:
            return True
    return False


def is_munki_recipe(recipe):
    '''Does the recipe appear to be for use with Munki?'''
    return recipe_has_step_processor(recipe, "MunkiImporter")
    
    
def has_check_phase(recipe):
    '''Does the recipe have a "check" phase?'''
    return recipe_has_step_processor(recipe, "EndOfCheckPhase")
    
    
def builds_a_package(recipe):
    '''Does this recipe build any packages?'''
    return recipe_has_step_processor(recipe, "PkgCreator")
    
    
def valid_plist_with_keys(filename, keys_to_verify):
    '''Attempts to read a plist file and ensures the keys in
    keys_to_verify exist. Returns False on any failure, True otherwise.'''
    try:
        # make sure we can read it
        recipe_plist = plistlib.readPlist(filename)
    except (IOError, OSError), err:
        log_err("WARNING: could not read plist at %s: %s" % (filename, err))
        return False
    except ExpatError:
        log_err("WARNING: invalid plist at %s" % filename)
        return False
    for key in keys_to_verify:
        if not key in recipe_plist:
            return False
    # if we get here, we found all the keys
    return True
    
    
def valid_recipe(filename):
    '''Returns True if filename contains a valid recipe, 
    otherwise returns False'''
    return valid_plist_with_keys(filename, ["Input", "Process"])
    
    
def valid_override(filename):
    '''Returns True if filename contains a valid override, 
    otherwise returns False'''
    return valid_plist_with_keys(filename, ["Input", "Recipe"])


def find_recipe(name, search_dirs, kind="default"):
    '''Search search_dirs for a recipe'''
    for directory in search_dirs:
        normalized_dir = os.path.abspath(os.path.expanduser(directory))
        patterns  = [
            os.path.join(normalized_dir, "%s.plist" % name),
            os.path.join(normalized_dir, "%s/%s.plist" % (name, name)),
            os.path.join(normalized_dir, "%s/_%s.plist" % (name, kind)),
            os.path.join(normalized_dir, "%s/_default.plist" % name),
            os.path.join(normalized_dir, "*/%s.plist" % name)
        ]
        for pattern in patterns:
            matches = glob.glob(pattern)
            for match in matches:
                if valid_recipe(match):
                    return match
    return None


def load_recipe(name, override_dirs, recipe_dirs, kind="default"):
    '''Loads a recipe. If the name contains a path seperator or a file
    extension , we assume the name is a pathname and attempt to load the
    override or recipe from the pathname. 
    Otherwise, we treat name as a simple name and search first the override
    directories, then the recipe directories for a matching recipe.
    If we find one, we load it and return the plist object (which should be 
    functionally equivelent to a dictionary).'''
    
    override = None
    if os.path.isfile(name):
        # name is path to a specific recipe or override file
        # ignore override and recipe directories and kind
        # and attempt to open the file specified by name
        if valid_override(name):
            override = plistlib.readPlist(name)
            override["OVERRIDE_PATH"] = os.path.abspath(name)
            name = override['Recipe'].get("name")
            kind = override['Recipe'].get("kind", "default")
            # fall through to get the actual recipe
        elif valid_recipe(name):
            recipe = plistlib.readPlist(name)
            recipe["RECIPE_PATH"] = os.path.abspath(name)
            return recipe
            
    if not override:
        # first look in override_dirs for name.plist
        filename = name + ".plist"
        for directory in override_dirs:
            normalized_dir = os.path.abspath(os.path.expanduser(directory))
            if filename in os.listdir(normalized_dir):
                pathname = os.path.join(normalized_dir, filename)
                if valid_override(pathname):
                    override = plistlib.readPlist(pathname)
                    name = override['Recipe'].get("name")
                    kind = override['Recipe'].get("kind", "default")
                    override["OVERRIDE_PATH"] = os.path.abspath(pathname)
                    break

    # now get the actual recipe
    if name:
        recipe_path = find_recipe(name, recipe_dirs, kind)
        if recipe_path:
            recipe = plistlib.readPlist(recipe_path)
            # apply overrides
            if override:
                recipe["OVERRIDE_PATH"] = override["OVERRIDE_PATH"]
                for key in override["Input"].keys():
                    recipe["Input"][key] = override["Input"][key]
            # store the recipe path for 
            recipe["RECIPE_PATH"] = os.path.abspath(recipe_path)
            return recipe
    return None


def get_recipe_info(recipe_name, override_dirs, recipe_dirs, kind="default"):
    '''Loads a recipe, then prints some information about it. Override aware.'''
    recipe = load_recipe(recipe_name, override_dirs, recipe_dirs, kind=kind)
    if recipe:
        log("Description:         %s" %
            "\n                     ".join(recipe.get("Description", "").splitlines()))
        log("Munki import recipe: %s" % is_munki_recipe(recipe))
        log("Has check phase:     %s" % has_check_phase(recipe))
        log("Builds package:      %s" % builds_a_package(recipe))
        log("Recipe file path:    %s" % recipe["RECIPE_PATH"])
        if recipe.get("OVERRIDE_PATH"):
            log("Override path:       %s" % recipe["OVERRIDE_PATH"])
        log("Input values: ")
        output = pprint.pformat(recipe.get("Input", {}), indent=4)
        log(" " + output[1:-1])
        return True
    else:
        return False


def list_recipes(override_dirs, search_dirs):
    '''Finds and lists all recipes in the current search paths.'''
    recipes = set()
    
    # find all valid overrides first
    for directory in override_dirs:
        normalized_dir = os.path.abspath(os.path.expanduser(directory))
        for filename in os.listdir(normalized_dir):
            if filename.endswith(".plist"):
                pathname = os.path.join(normalized_dir, filename)
                if valid_override(pathname):
                    override = plistlib.readPlist(pathname)
                    name = override['Recipe'].get("name")
                    kind = override['Recipe'].get("kind", "default")
                    # find and validate recipe that will be overridden
                    recipe_path = find_recipe(name, search_dirs, kind)
                    if recipe_path and valid_recipe(recipe_path):
                        # override points to a valid recipe
                        recipes.add(os.path.splitext(filename)[0])
    
    for directory in search_dirs:
        normalized_dir = os.path.abspath(os.path.expanduser(directory))
        # find all top-level recipes
        matches = glob.glob(os.path.join(normalized_dir, "*.plist"))
        for match in matches:
            if valid_recipe(match):
                basename = os.path.basename(match)
                recipes.add(os.path.splitext(basename)[0])

        # look for recipes one level down
        matches = glob.glob(os.path.join(normalized_dir, "*/*.plist"))
        for match in matches:
            if valid_recipe(match):
                recipe_name = os.path.basename(match)
                if recipe_name.startswith("_"):
                    # recipe variation or "kind"
                    # use the dirname
                    dirname = os.path.dirname(match)
                    recipe_name = os.path.basename(dirname)

                # get rid of file extension
                recipe_name = os.path.splitext(recipe_name)[0]
                recipes.add(recipe_name)

    return sorted(list(recipes))


def makecatalogs(repo_path):
    '''Rebuild Munki catalogs in repo_path'''
    # Generate arguments for makecatalogs.
    args = ["/usr/local/munki/makecatalogs", repo_path]
    
    # Call makecatalogs.
    try:
        proc = subprocess.Popen(
            args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        (out, err_out) = proc.communicate()
    except OSError as err:
        log_err("makecatalog execution failed with error code %d: %s" 
                % (err.errno, err.strerror))
    if proc.returncode != 0:
        log_err("makecatalogs failed: %s" % err_out)
    log("Munki catalogs rebuilt!")


def print_new_imports(new_imports):
    '''Print new Munki imports in a table format'''
    log("    %-24s %-16s %-32s %s" % (
        "Name", "Version", "Catalogs", "Pkginfo Path"))
    log("    %-24s %-16s %-32s %s" % (
        "----", "-------", "--------", "------------"))
    for item in new_imports:
        log("    %-24s %-16s %-32s %s" % (
            item["name"], item["version"], item["catalogs"],
            item["pkginfo_path"].partition("pkgsinfo/")[2]))


def main(argv):
    # Parse arguments.
    p = optparse.OptionParser()
    p.set_usage("Usage: %prog [options] [recipe ...]")
    p.add_option("-c", "--check", action="store_true",
                 help="Only check for new/changed downloads.")
    p.add_option("-d", "--search-dir", metavar="DIRECTORY", dest="search_dirs",
                 action="append", default=[],
                 help=("Directory to search for recipes. Can be specified "
                                    "multiple times."))
    p.add_option("-k", "--key", action="append", dest="variables", default=[],
                 metavar="KEY=VALUE",
                 help=("Provide key/value pairs for recipe input. "
                       "Caution: values specified here will be applied "
                       "to all recipes."))
    p.add_option("-l", "--recipe-list", metavar="TEXT_FILE",
                 help="Path to a text file with a list of recipes to run.")
    p.add_option("--list-recipes", dest="list_recipes", action="store_true",
                 help="List all the recipes this tool can find automatically.")
    p.add_option("--override-dir", metavar="DIRECTORY", dest="override_dirs",
                 action="append", default=[],
                 help=("Directory to search for recipe overrides. Can be "
                       "specified multiple times."))
    p.add_option("--make-override", metavar="RECIPE", action="store_true",
                 help=("Create a skeleton override file for a recipe. It will "
                       "be stored in the first default override directory "
                       "or that given by '--override-dir'"))
    p.add_option("-p", "--pkg", metavar="PKG_OR_DMG",
                 help=("Path to a pkg or dmg to provide to a recipe. "
                       "Downloading will be skipped."))
    p.add_option("--recipe-info", "--info", metavar="RECIPE_NAME", 
                 dest="recipe_info", action="store_true",
                 help="Print information about a recipe.")
    p.add_option("--report-plist", action="store_true", default=False,
                 help="Output run report data in plist format to stdout. "
                      "Additional messages may still be printed to stderr.")
    p.add_option("-v", "--verbose", action="count", default=0,
                 help="Verbose output.")
    options, argv = p.parse_args(argv)
    
    munki_repo_path = None
    new_downloads = []
    new_imports = []
    failures = []
    
    # Set up search dirs for recipes and overrides. Defaults below are set
    # if we don't have the RECIPE_SEARCH_DIRS or RECIPE_OVERRIDE_DIRS
    # set via defaults, and --search-dir/--override-dirs always take
    # precedence. These 'sources' for the preference aren't composited
    # into a master list; it's one of the baked-in defaults, pref key or
    # CLI options, no combination.
    default_search_dirs = [
        ".",
        "~/Library/AutoPkg/Recipes",
        "/Library/AutoPkg/Recipes"
        ]
    
    default_override_dirs = [
        "~/Library/AutoPkg/RecipeOverrides"
        ]
        

    if not options.search_dirs:
        if get_pref("RECIPE_SEARCH_DIRS"):
            options.search_dirs = get_pref("RECIPE_SEARCH_DIRS")
            if isinstance(options.search_dirs, basestring):
                options.search_dirs = [options.search_dirs]
        else:
            options.search_dirs = default_search_dirs
        
    if not options.override_dirs:
        if get_pref("RECIPE_OVERRIDE_DIRS"):
            options.override_dirs = get_pref("RECIPE_OVERRIDE_DIRS")
            if isinstance(options.override_dirs, basestring):
                options.override_dirs = [options.override_dirs]
        else:
            options.override_dirs = default_override_dirs
    
    if options.list_recipes:
        recipe_list = list_recipes(options.override_dirs, options.search_dirs)
        print "\n".join(recipe_list)
        return 0
    
    # Add variables from commandline.
    cli_values = {}
    for arg in options.variables:
        (key, sep, value) = arg.partition("=")
        if sep != "=":
            log_err("Invalid variable [key=value]: %s" % arg)
            log_err(p.get_usage())
            return 1
        cli_values[key] = value
        
    if options.pkg:
        cli_values["PKG"] = options.pkg
        
    # so that log() can make use of it
    global _report_plist 
    _report_plist = options.report_plist
    if _report_plist:
        options.verbose = 0

    recipe_paths = []
    recipe_paths.extend(argv[1:])
    if options.recipe_list:
        with open(options.recipe_list, "r") as fd:
            data = fd.read()
        recipes = [line for line in data.splitlines() 
                   if line and not line.startswith("#")]
        recipe_paths.extend(recipes)
    
    if not recipe_paths:
        log_err(p.get_usage())
        return 1
        
    if len(recipe_paths) > 1 and options.pkg:
        log_err("-p/--pkg option can't be used with multiple recipes!")
        return 1
        
    if options.recipe_info:
        if get_recipe_info(recipe_paths[0], 
                options.override_dirs, options.search_dirs, kind="munki"):
            return 0
        else:
            log_err("Can't find recipe %s" % recipe_paths[0])
            return -1
    
    if options.make_override:
        override_name = recipe_paths[0]
        if os.path.isfile(override_name):
            log_err("--make-override doesn't work with absolute recipe paths, "
                    "as it may not be able to correctly determine the value "
                    "for 'name' that would be searched in recipe directories.")
            return 1
        # prefer 'munki' kind, but this will fall back to _default, etc.
        override_kind = "munki"
        recipe = load_recipe(override_name,
                                override_dirs=[],
                                recipe_dirs=options.search_dirs,
                                kind=override_kind)
        if not recipe:
            log_err("No valid recipe found for %s" % override_name)
            log_err("Dir(s) searched: %s" % ":".join(options.search_dirs))
            return 1
        override_plist = dict({"Recipe":
                                {"kind": override_kind,
                                 "name": override_name}
                               }
                              )
        override_plist["Input"] = recipe["Input"]

        override_out_file = os.path.expanduser(
            os.path.join(options.override_dirs[0], "%s.plist" % override_name))
        if os.path.exists(override_out_file):
            log_err("An override plist already exists at %s, will not overwrite it."
                    % override_out_file)
            return 1
        else:
            plistlib.writePlist(override_plist, override_out_file)
            log("Override file saved to %s." % override_out_file)
            return 0

    run_results = []
    for recipe_path in recipe_paths:
        recipe = load_recipe(recipe_path, 
                    options.override_dirs, options.search_dirs, kind="munki")
        if not recipe:
            log_err("No valid recipe found for %s" % recipe_path)
            continue
            
        if not is_munki_recipe(recipe):
            log_err("WARNING: %s does not appear to be a Munki recipe."
                    % recipe_path)
        
        if options.check:
            # remove steps from the end of the recipe Process until we find a 
            # EndOfCheckPhase step
            while len(recipe["Process"]) >= 1 and \
                    recipe["Process"][-1]["Processor"] != "EndOfCheckPhase":
                del recipe["Process"][-1]
            if len(recipe["Process"]) == 0:
                log_err("Recipe at %s is missing EndOfCheckPhase Processor, "
                        "not possible to perform check." % recipe_path)
                continue
    
        log("Processing %s..." % recipe_path)
        # Obtain prefs from the defaults domain
        prefs = get_all_prefs()
        # Add RECIPE_PATH and RECIPE_DIR variables for use by processors
        prefs["RECIPE_PATH"] = os.path.abspath(recipe["RECIPE_PATH"])
        prefs["RECIPE_DIR"] = os.path.dirname(prefs["RECIPE_PATH"])
    
        # Add our verbosity level
        prefs["verbose"] = options.verbose
    
        autopackager = AutoPackager(options, prefs)
    
        exit_status = 0
        exit_string = ""
    
        try:
            autopackager.process_input_overrides(recipe, cli_values)
            autopackager.verify(recipe)
            autopackager.process(recipe)
        except AutoPackagerError as e:
            log_err("Failed.")
            failure = {}
            failure["recipe"] = recipe_path
            failure["message"] = str(e)
            failures.append(failure)

        run_results.append(autopackager.results)
        # look through results for interesting info
        # and record for later summary and use
        for item in autopackager.results:
            if item.get("Recipe input"):
                # the assumption is that the MUNKI_REPO does not
                # change from recipe to recipe; if it does, running
                # makecatalogs later will affect only the "last"
                # MUNKI_REPO
                munki_repo_path = item["Recipe input"].get("MUNKI_REPO")
            if item.get("Processor") == "URLDownloader":
                if item["Output"].get("download_changed"):
                    new_downloads.append(item["Output"].get("pathname"))
            if item.get("Processor") == "MunkiImporter":
                if item["Output"].get("pkginfo_repo_path"):
                    imported_item = {}
                    imported_item["name"] = item["Output"]["munki_info"]["name"]
                    imported_item["version"] = item[
                                            "Output"]["munki_info"]["version"]
                    imported_item["catalogs"] = item[
                                            "Output"]["munki_info"]["catalogs"]
                    imported_item["pkginfo_path"] = item["Output"].get(
                                                            "pkginfo_repo_path")
                    imported_item["pkg_path"] = item["Output"].get(
                                                                "pkg_repo_path")
                    new_imports.append(imported_item)
                         
    # done running recipes, print a summary
    if failures:
        log("\nThe following recipes failed:")
        for item in failures:
            log("    %s" % item["recipe"])
            log("        %s" % item["message"])
        
    if new_downloads:
        log("\nThe following new items were downloaded:")
        for item in new_downloads:
            log("    %s" % item)
    
    if new_imports:
        log("\nThe following new items were imported:")
        print_new_imports(new_imports)
        log("\n")
        # rebuild Munki catalogs since we imported new items
        makecatalogs(munki_repo_path)
            
    if not new_downloads and not new_imports:
        log("\nNo changes found.")
        
    if _report_plist:
        results_report = {}
        for event_type in ["failures", "new_downloads", "new_imports"]:
            results_report[event_type] = locals()[event_type]
        print plistlib.writePlistToString(results_report)
        

if __name__ == "__main__":
    sys.exit(main(sys.argv))
    
